// @ts-nocheck
const fs = require('fs')
const assert = require('assert')
const path = require('path')
const qrcode = require('qrcode-terminal')
const createTestCafe = require('testcafe')
const { Selector, ClientFunction } = require('testcafe')

const Helper = require('@codeceptjs/helper')
const ElementNotFound = require('./errors/ElementNotFound')
const testControllerHolder = require('./testcafe/testControllerHolder')
const { mapError, createTestFile, createClientFunction } = require('./testcafe/testcafe-utils')

const stringIncludes = require('../assert/include').includes
const { urlEquals } = require('../assert/equal')
const { empty } = require('../assert/empty')
const { truth } = require('../assert/truth')
const { xpathLocator, normalizeSpacesInString } = require('../utils')
const Locator = require('../locator')

/**
 * Client Functions
 */
const getPageUrl = t => ClientFunction(() => document.location.href).with({ boundTestRun: t })
const getHtmlSource = t => ClientFunction(() => document.getElementsByTagName('html')[0].innerHTML).with({ boundTestRun: t })

/**
 * Uses [TestCafe](https://github.com/DevExpress/testcafe) library to run cross-browser tests.
 * The browser version you want to use in tests must be installed on your system.
 *
 * Requires `testcafe` package to be installed.
 *
 * ```
 * npm i testcafe --save-dev
 * ```
 *
 * ## Configuration
 *
 * This helper should be configured in codecept.conf.ts or codecept.conf.js
 *
 * * `url`: base url of website to be tested
 * * `show`: (optional, default: false) - show browser window.
 * * `windowSize`: (optional) - set browser window width and height
 * * `getPageTimeout` (optional, default: '30000') config option to set maximum navigation time in milliseconds.
 * * `waitForTimeout`: (optional) default wait* timeout in ms. Default: 5000.
 * * `browser`: (optional, default: chrome)  - See https://devexpress.github.io/testcafe/documentation/using-testcafe/common-concepts/browsers/browser-support.html
 *
 *
 * #### Example #1: Show chrome browser window
 *
 * ```js
 * {
 *    helpers: {
 *      TestCafe : {
 *        url: "http://localhost",
 *        waitForTimeout: 15000,
 *        show: true,
 *        browser: "chrome"
 *      }
 *    }
 * }
 * ```
 *
 *  To use remote device you can provide 'remote' as browser parameter this will display a link with QR Code
 *  See https://devexpress.github.io/testcafe/documentation/recipes/test-on-remote-computers-and-mobile-devices.html
 *  #### Example #2: Remote browser connection
 *
 * ```js
 * {
 *    helpers: {
 *      TestCafe : {
 *        url: "http://localhost",
 *        waitForTimeout: 15000,
 *        browser: "remote"
 *      }
 *    }
 * }
 * ```
 *
 * ## Access From Helpers
 *
 * Call Testcafe methods directly using the testcafe controller.
 *
 * ```js
 * const testcafeTestController = this.helpers['TestCafe'].t;
 * const comboBox = Selector('.combo-box');
 * await testcafeTestController
 *   .hover(comboBox) // hover over combo box
 *   .click('#i-prefer-both') // click some other element
 * ```
 *
 * ## Methods
 */
class TestCafe extends Helper {
  constructor(config) {
    super(config)

    this.testcafe = undefined // testcafe instance
    this.t = undefined // testcafe test controller
    this.dummyTestcafeFile // generated testcafe test file

    // context is used for within() function.
    // It requires to have _withinBeginand _withinEnd implemented.
    // Inside _withinBegin we should define that all next element calls should be started from a specific element (this.context).
    this.context = undefined // TODO Not sure if this applies to testcafe

    this.options = {
      url: 'http://localhost',
      show: false,
      browser: 'chrome',
      restart: true, // TODO Test if restart false works
      manualStart: false,
      keepBrowserState: false,
      waitForTimeout: 5000,
      getPageTimeout: 30000,
      fullPageScreenshots: false,
      disableScreenshots: false,
      windowSize: undefined,
      ...config,
    }
  }

  // TOOD Do a requirements check
  static _checkRequirements() {
    try {
      require('testcafe')
    } catch (e) {
      return ['testcafe@^1.1.0']
    }
  }

  static _config() {
    return [
      { name: 'url', message: 'Base url of site to be tested', default: 'http://localhost' },
      { name: 'browser', message: 'Browser to be used', default: 'chrome' },
      {
        name: 'show',
        message: 'Show browser window',
        default: true,
        type: 'confirm',
      },
    ]
  }

  async _configureAndStartBrowser() {
    this.dummyTestcafeFile = createTestFile(global.output_dir) // create a dummy test file to get hold of the test controller

    this.iteration += 2 // Use different ports for each test run
    // @ts-ignore
    this.testcafe = await createTestCafe('', null, null)

    this.debugSection('_before', 'Starting testcafe browser...')

    this.isRunning = true

    // TODO Do we have to cleanup the runner?
    const runner = this.testcafe.createRunner()

    this.options.browser !== 'remote' ? this._startBrowser(runner) : this._startRemoteBrowser(runner)

    this.t = await testControllerHolder.get()
    assert(this.t, 'Expected to have the testcafe test controller')

    if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0) {
      const dimensions = this.options.windowSize.split('x')
      await this.t.resizeWindow(parseInt(dimensions[0], 10), parseInt(dimensions[1], 10))
    }
  }

  async _startBrowser(runner) {
    runner
      .src(this.dummyTestcafeFile)
      .screenshots(global.output_dir, !this.options.disableScreenshots)
      // .video(global.output_dir) // TODO Make this configurable
      .browsers(this.options.show ? this.options.browser : `${this.options.browser}:headless`)
      .reporter('minimal')
      .run({
        skipJsErrors: true,
        skipUncaughtErrors: true,
        quarantineMode: false,
        // debugMode: true,
        // debugOnFail: true,
        // developmentMode: true,
        pageLoadTimeout: this.options.getPageTimeout,
        selectorTimeout: this.options.waitForTimeout,
        assertionTimeout: this.options.waitForTimeout,
        takeScreenshotsOnFails: true,
      })
      .catch(err => {
        this.debugSection('_before', `Error ${err.toString()}`)
        this.isRunning = false
        this.testcafe.close()
      })
  }

  async _startRemoteBrowser(runner) {
    const remoteConnection = await this.testcafe.createBrowserConnection()
    console.log('Connect your device to the following URL or scan QR Code: ', remoteConnection.url)
    qrcode.generate(remoteConnection.url)
    remoteConnection.once('ready', () => {
      runner
        .src(this.dummyTestcafeFile)
        .browsers(remoteConnection)
        .reporter('minimal')
        .run({
          selectorTimeout: this.options.waitForTimeout,
          skipJsErrors: true,
          skipUncaughtErrors: true,
        })
        .catch(err => {
          this.debugSection('_before', `Error ${err.toString()}`)
          this.isRunning = false
          this.testcafe.close()
        })
    })
  }

  async _stopBrowser() {
    this.debugSection('_after', 'Stopping testcafe browser...')

    testControllerHolder.free()
    if (this.testcafe) {
      this.testcafe.close()
    }

    fs.unlinkSync(this.dummyTestcafeFile) // remove the dummy test
    this.t = undefined

    this.isRunning = false
  }

  _init() {}

  async _beforeSuite() {
    if (!this.options.restart && !this.options.manualStart && !this.isRunning) {
      this.debugSection('Session', 'Starting singleton browser session')
      return this._configureAndStartBrowser()
    }
  }

  async _before() {
    if (this.options.restart && !this.options.manualStart) return this._configureAndStartBrowser()
    if (!this.isRunning && !this.options.manualStart) return this._configureAndStartBrowser()
    this.context = null
  }

  async _after() {
    if (!this.isRunning) return

    if (this.options.restart) {
      this.isRunning = false
      return this._stopBrowser()
    }

    if (this.options.keepBrowserState) return

    if (!this.options.keepCookies) {
      this.debugSection('Session', 'cleaning cookies and localStorage')
      await this.clearCookie()

      // TODO IMHO that should only happen when
      await this.executeScript(() => localStorage.clear()).catch(err => {
        if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
      })
    }
  }

  _afterSuite() {}

  async _finishTest() {
    if (!this.options.restart && this.isRunning) return this._stopBrowser()
  }

  /**
   * Use [TestCafe](https://devexpress.github.io/testcafe/documentation/test-api/) API inside a test.
   *
   * First argument is a description of an action.
   * Second argument is async function that gets this helper as parameter.
   *
   * { [`t`](https://devexpress.github.io/testcafe/documentation/test-api/test-code-structure.html#test-controller)) } object from TestCafe API is available.
   *
   * ```js
   * I.useTestCafeTo('handle browser dialog', async ({ t }) {
   *   await t.setNativeDialogHandler(() => true);
   * });
   * ```
   *
   *
   *
   * @param {string} description used to show in logs.
   * @param {function} fn async functuion that executed with TestCafe helper as argument
   */
  useTestCafeTo(description, fn) {
    return this._useTo(...arguments)
  }

  /**
   * Get elements by different locator types, including strict locator
   * Should be used in custom helpers:
   *
   * ```js
   * const elements = await this.helpers['TestCafe']._locate('.item');
   * ```
   *
   */
  async _locate(locator) {
    return findElements.call(this, this.context, locator).catch(mapError)
  }

  async _withinBegin(locator) {
    const els = await this._locate(locator)
    assertElementExists(els, locator)
    this.context = await els.nth(0)
  }

  async _withinEnd() {
    this.context = null
  }

  /**
   * {{> amOnPage }}
   */
  async amOnPage(url) {
    if (!/^\w+\:\/\//.test(url)) {
      url = this.options.url + url
    }

    return this.t.navigateTo(url).catch(mapError)
  }

  /**
   * {{> resizeWindow }}
   */
  async resizeWindow(width, height) {
    if (width === 'maximize') {
      return this.t.maximizeWindow().catch(mapError)
    }

    return this.t.resizeWindow(width, height).catch(mapError)
  }

  /**
   * {{> focus }}
   *
   */
  async focus(locator) {
    const els = await this._locate(locator)
    await assertElementExists(els, locator, 'Element to focus')
    const element = await els.nth(0)

    const focusElement = ClientFunction(() => element().focus(), {
      boundTestRun: this.t,
      dependencies: { element },
    })

    return focusElement()
  }

  /**
   * {{> blur }}
   *
   */
  async blur(locator) {
    const els = await this._locate(locator)
    await assertElementExists(els, locator, 'Element to blur')
    const element = await els.nth(0)

    const blurElement = ClientFunction(() => element().blur(), { boundTestRun: this.t, dependencies: { element } })

    return blurElement()
  }

  /**
   * {{> click }}
   *
   */
  async click(locator, context = null) {
    return proceedClick.call(this, locator, context)
  }

  /**
   * {{> refreshPage }}
   */
  async refreshPage() {
    return this.t.eval(() => location.reload(true), { boundTestRun: this.t }).catch(mapError)
  }

  /**
   * {{> waitForVisible }}
   *
   */
  async waitForVisible(locator, sec) {
    const timeout = sec ? sec * 1000 : undefined

    return (await findElements.call(this, this.context, locator)).with({ visibilityCheck: true, timeout })().catch(mapError)
  }

  /**
   * {{> fillField }}
   */
  async fillField(field, value) {
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)
    return this.t.typeText(el, value.toString(), { replace: true }).catch(mapError)
  }

  /**
   * {{> clearField }}
   */
  async clearField(field) {
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)

    const res = await this.t.selectText(el).pressKey('delete')
    return res
  }

  /**
   * {{> appendField }}
   *
   */
  async appendField(field, value) {
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)

    return this.t.typeText(el, value.toString(), { replace: false }).catch(mapError)
  }

  /**
   * {{> attachFile }}
   *
   */
  async attachFile(field, pathToFile) {
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)
    const file = path.join(global.codecept_dir, pathToFile)

    return this.t.setFilesToUpload(el, [file]).catch(mapError)
  }

  /**
   * {{> pressKey }}
   *
   * {{ keys }}
   */
  async pressKey(key) {
    assert(key, 'Expected a sequence of keys or key combinations')

    return this.t
      .pressKey(key.toLowerCase()) // testcafe keys are lowercase
      .catch(mapError)
  }

  /**
   * {{> moveCursorTo }}
   *
   */
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
    const els = (await findElements.call(this, this.context, locator)).filterVisible()
    await assertElementExists(els, locator)

    return this.t.hover(els.nth(0), { offsetX, offsetY }).catch(mapError)
  }

  /**
   * {{> doubleClick }}
   *
   */
  async doubleClick(locator, context = null) {
    let matcher
    if (context) {
      const els = await this._locate(context)
      await assertElementExists(els, context)
      matcher = await els.nth(0)
    }

    const els = (await findClickable.call(this, matcher, locator)).filterVisible()
    return this.t.doubleClick(els.nth(0)).catch(mapError)
  }

  /**
   * {{> rightClick }}
   *
   */
  async rightClick(locator, context = null) {
    let matcher
    if (context) {
      const els = await this._locate(context)
      await assertElementExists(els, context)
      matcher = await els.nth(0)
    }
    const els = (await findClickable.call(this, matcher, locator)).filterVisible()
    assertElementExists(els, locator)
    return this.t.rightClick(els.nth(0)).catch(mapError)
  }

  /**
   * {{> checkOption }}
   */
  async checkOption(field, context = null) {
    const el = await findCheckable.call(this, field, context)

    return this.t.click(el).catch(mapError)
  }

  /**
   * {{> uncheckOption }}
   */
  async uncheckOption(field, context = null) {
    const el = await findCheckable.call(this, field, context)

    if (await el.checked) {
      return this.t.click(el).catch(mapError)
    }
  }

  /**
   * {{> seeCheckboxIsChecked }}
   */
  async seeCheckboxIsChecked(field) {
    return proceedIsChecked.call(this, 'assert', field)
  }

  /**
   * {{> dontSeeCheckboxIsChecked }}
   */
  async dontSeeCheckboxIsChecked(field) {
    return proceedIsChecked.call(this, 'negate', field)
  }

  /**
   * {{> selectOption }}
   */
  async selectOption(select, option) {
    const els = await findFields.call(this, select)
    assertElementExists(els, select, 'Selectable field')

    const el = await els.filterVisible().nth(0)

    if ((await el.tagName).toLowerCase() !== 'select') {
      throw new Error('Element is not <select>')
    }
    if (!Array.isArray(option)) option = [option]

    // TODO As far as I understand the testcafe docs this should do a multi-select
    // but it does not work
    // const clickOpts = { ctrl: option.length > 1 };
    await this.t.click(el).catch(mapError)

    for (const key of option) {
      const opt = key

      let optEl
      try {
        optEl = el.child('option').withText(opt)
        if (await optEl.count) {
          await this.t.click(optEl).catch(mapError)
          continue
        }
      } catch (err) {}

      try {
        const sel = `[value="${opt}"]`
        optEl = el.find(sel)
        if (await optEl.count) {
          await this.t.click(optEl).catch(mapError)
        }
      } catch (err) {}
    }
  }

  /**
   * {{> seeInCurrentUrl }}
   */
  async seeInCurrentUrl(url) {
    stringIncludes('url').assert(url, await getPageUrl(this.t)().catch(mapError))
  }

  /**
   * {{> dontSeeInCurrentUrl }}
   */
  async dontSeeInCurrentUrl(url) {
    stringIncludes('url').negate(url, await getPageUrl(this.t)().catch(mapError))
  }

  /**
   * {{> seeCurrentUrlEquals }}
   */
  async seeCurrentUrlEquals(url) {
    urlEquals(this.options.url).assert(url, await getPageUrl(this.t)().catch(mapError))
  }

  /**
   * {{> dontSeeCurrentUrlEquals }}
   */
  async dontSeeCurrentUrlEquals(url) {
    urlEquals(this.options.url).negate(url, await getPageUrl(this.t)().catch(mapError))
  }

  /**
   * {{> see }}
   *
   */
  async see(text, context = null) {
    let els
    if (context) {
      els = (await findElements.call(this, this.context, context)).withText(normalizeSpacesInString(text))
    } else {
      els = (await findElements.call(this, this.context, '*')).withText(normalizeSpacesInString(text))
    }

    return this.t.expect(els.filterVisible().count).gt(0, `No element with text "${text}" found`).catch(mapError)
  }

  /**
   * {{> dontSee }}
   *
   */
  async dontSee(text, context = null) {
    let els
    if (context) {
      els = (await findElements.call(this, this.context, context)).withText(text)
    } else {
      els = (await findElements.call(this, this.context, 'body')).withText(text)
    }

    return this.t.expect(els.filterVisible().count).eql(0, `Element with text "${text}" can still be seen`).catch(mapError)
  }

  /**
   * {{> seeElement }}
   */
  async seeElement(locator) {
    const exists = (await findElements.call(this, this.context, locator)).filterVisible().exists
    return this.t
      .expect(exists)
      .ok(`No element "${new Locator(locator)}" found`)
      .catch(mapError)
  }

  /**
   * {{> dontSeeElement }}
   */
  async dontSeeElement(locator) {
    const exists = (await findElements.call(this, this.context, locator)).filterVisible().exists
    return this.t
      .expect(exists)
      .notOk(`Element "${new Locator(locator)}" is still visible`)
      .catch(mapError)
  }

  /**
   * {{> seeElementInDOM }}
   */
  async seeElementInDOM(locator) {
    const exists = (await findElements.call(this, this.context, locator)).exists
    return this.t
      .expect(exists)
      .ok(`No element "${new Locator(locator)}" found in DOM`)
      .catch(mapError)
  }

  /**
   * {{> dontSeeElementInDOM }}
   */
  async dontSeeElementInDOM(locator) {
    const exists = (await findElements.call(this, this.context, locator)).exists
    return this.t
      .expect(exists)
      .notOk(`Element "${new Locator(locator)}" is still in DOM`)
      .catch(mapError)
  }

  /**
   * {{> seeNumberOfVisibleElements }}
   *
   */
  async seeNumberOfVisibleElements(locator, num) {
    const count = (await findElements.call(this, this.context, locator)).filterVisible().count
    return this.t.expect(count).eql(num).catch(mapError)
  }

  /**
   * {{> grabNumberOfVisibleElements }}
   */
  async grabNumberOfVisibleElements(locator) {
    const count = (await findElements.call(this, this.context, locator)).filterVisible().count
    return count
  }

  /**
   * {{> seeInField }}
   */
  async seeInField(field, value) {
    const _value = typeof value === 'boolean' ? value : value.toString()
    // const expectedValue = findElements.call(this, this.context, field).value;
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)

    return this.t
      .expect(await el.value)
      .eql(_value)
      .catch(mapError)
  }

  /**
   * {{> dontSeeInField }}
   */
  async dontSeeInField(field, value) {
    const _value = typeof value === 'boolean' ? value : value.toString()
    // const expectedValue = findElements.call(this, this.context, field).value;
    const els = await findFields.call(this, field)
    assertElementExists(els, field, 'Field')
    const el = await els.nth(0)

    return this.t.expect(el.value).notEql(_value).catch(mapError)
  }

  /**
   * Checks that text is equal to provided one.
   *
   * ```js
   * I.seeTextEquals('text', 'h1');
   * ```
   */
  async seeTextEquals(text, context = null) {
    const expectedText = findElements.call(this, context, undefined).textContent
    return this.t.expect(expectedText).eql(text).catch(mapError)
  }

  /**
   * {{> seeInSource }}
   */
  async seeInSource(text) {
    const source = await getHtmlSource(this.t)()
    stringIncludes('HTML source of a page').assert(text, source)
  }

  /**
   * {{> dontSeeInSource }}
   */
  async dontSeeInSource(text) {
    const source = await getHtmlSource(this.t)()
    stringIncludes('HTML source of a page').negate(text, source)
  }

  /**
   * {{> saveElementScreenshot }}
   *
   */
  async saveElementScreenshot(locator, fileName) {
    const outputFile = path.join(global.output_dir, fileName)

    const sel = await findElements.call(this, this.context, locator)
    assertElementExists(sel, locator)
    const firstElement = await sel.filterVisible().nth(0)

    this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`)
    return this.t.takeElementScreenshot(firstElement, fileName)
  }

  /**
   * {{> saveScreenshot }}
   */
  // TODO Implement full page screenshots
  async saveScreenshot(fileName) {
    const outputFile = path.join(global.output_dir, fileName)
    this.debug(`Screenshot is saving to ${outputFile}`)

    // TODO testcafe automatically creates thumbnail images (which cant be turned off)
    return this.t.takeScreenshot(fileName)
  }

  /**
   * {{> wait }}
   */
  async wait(sec) {
    return new Promise(done => {
      setTimeout(done, sec * 1000)
    })
  }

  /**
   * {{> executeScript }}
   *
   * If a function returns a Promise It will wait for its resolution.
   */
  async executeScript(fn, ...args) {
    const browserFn = createClientFunction(fn, args).with({ boundTestRun: this.t })
    return browserFn()
  }

  /**
   * {{> grabTextFromAll }}
   */
  async grabTextFromAll(locator) {
    const sel = await findElements.call(this, this.context, locator)
    const length = await sel.count
    const texts = []
    for (let i = 0; i < length; i++) {
      texts.push(await sel.nth(i).innerText)
    }

    return texts
  }

  /**
   * {{> grabTextFrom }}
   */
  async grabTextFrom(locator) {
    const sel = await findElements.call(this, this.context, locator)
    assertElementExists(sel, locator)
    const texts = await this.grabTextFromAll(locator)
    if (texts.length > 1) {
      this.debugSection('GrabText', `Using first element out of ${texts.length}`)
    }

    return texts[0]
  }

  /**
   * {{> grabAttributeFrom }}
   */
  async grabAttributeFromAll(locator, attr) {
    const sel = await findElements.call(this, this.context, locator)
    const length = await sel.count
    const attrs = []
    for (let i = 0; i < length; i++) {
      attrs.push(await (await sel.nth(i)).getAttribute(attr))
    }

    return attrs
  }

  /**
   * {{> grabAttributeFrom }}
   */
  async grabAttributeFrom(locator, attr) {
    const sel = await findElements.call(this, this.context, locator)
    assertElementExists(sel, locator)
    const attrs = await this.grabAttributeFromAll(locator, attr)
    if (attrs.length > 1) {
      this.debugSection('GrabAttribute', `Using first element out of ${attrs.length}`)
    }

    return attrs[0]
  }

  /**
   * {{> grabValueFromAll }}
   */
  async grabValueFromAll(locator) {
    const sel = await findElements.call(this, this.context, locator)
    const length = await sel.count
    const values = []
    for (let i = 0; i < length; i++) {
      values.push(await (await sel.nth(i)).value)
    }

    return values
  }

  /**
   * {{> grabValueFrom }}
   */
  async grabValueFrom(locator) {
    const sel = await findElements.call(this, this.context, locator)
    assertElementExists(sel, locator)
    const values = await this.grabValueFromAll(locator)
    if (values.length > 1) {
      this.debugSection('GrabValue', `Using first element out of ${values.length}`)
    }

    return values[0]
  }

  /**
   * {{> grabSource }}
   */
  async grabSource() {
    return ClientFunction(() => document.documentElement.innerHTML).with({ boundTestRun: this.t })()
  }

  /**
   * Get JS log from browser.
   *
   * ```js
   * let logs = await I.grabBrowserLogs();
   * console.log(JSON.stringify(logs))
   * ```
   */
  async grabBrowserLogs() {
    // TODO Must map?
    return this.t.getBrowserConsoleMessages()
  }

  /**
   * {{> grabCurrentUrl }}
   */
  async grabCurrentUrl() {
    return ClientFunction(() => document.location.href).with({ boundTestRun: this.t })()
  }

  /**
   * {{> grabPageScrollPosition }}
   */
  async grabPageScrollPosition() {
    return ClientFunction(() => ({ x: window.pageXOffset, y: window.pageYOffset })).with({ boundTestRun: this.t })()
  }

  /**
   * {{> scrollPageToTop }}
   */
  scrollPageToTop() {
    return ClientFunction(() => window.scrollTo(0, 0))
      .with({ boundTestRun: this.t })()
      .catch(mapError)
  }

  /**
   * {{> scrollPageToBottom }}
   */
  scrollPageToBottom() {
    return ClientFunction(() => {
      const body = document.body
      const html = document.documentElement
      window.scrollTo(0, Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight))
    })
      .with({ boundTestRun: this.t })()
      .catch(mapError)
  }

  /**
   * {{> scrollTo }}
   */
  async scrollTo(locator, offsetX = 0, offsetY = 0) {
    if (typeof locator === 'number' && typeof offsetX === 'number') {
      offsetY = offsetX
      offsetX = locator
      locator = null
    }

    const scrollBy = ClientFunction(offset => {
      if (window && window.scrollBy && offset) {
        window.scrollBy(offset.x, offset.y)
      }
    }).with({ boundTestRun: this.t })

    if (locator) {
      const els = await this._locate(locator)
      assertElementExists(els, locator, 'Element')
      const el = await els.nth(0)
      const x = (await el.offsetLeft) + offsetX
      const y = (await el.offsetTop) + offsetY

      return scrollBy({ x, y }).catch(mapError)
    }

    const x = offsetX
    const y = offsetY
    return scrollBy({ x, y }).catch(mapError)
  }

  /**
   * {{> switchTo }}
   */
  async switchTo(locator) {
    if (Number.isInteger(locator)) {
      throw new Error('Not supported switching to iframe by number')
    }

    if (!locator) {
      return this.t.switchToMainWindow()
    }

    const el = await findElements.call(this, this.context, locator)
    return this.t.switchToIframe(el)
  }

  // TODO Add url assertions

  /**
   * {{> setCookie }}
   */
  async setCookie(cookie) {
    if (Array.isArray(cookie)) {
      throw new Error('cookie array is not supported')
    }

    cookie.path = cookie.path || '/'
    // cookie.expires = cookie.expires || (new Date()).toUTCString();

    const setCookie = ClientFunction(
      () => {
        document.cookie = `${cookie.name}=${cookie.value};path=${cookie.path};expires=${cookie.expires};`
      },
      { dependencies: { cookie } },
    ).with({ boundTestRun: this.t })

    return setCookie()
  }

  /**
   * {{> seeCookie }}
   *
   */
  async seeCookie(name) {
    const cookie = await this.grabCookie(name)
    empty(`cookie ${name} to be set`).negate(cookie)
  }

  /**
   * {{> dontSeeCookie }}
   */
  async dontSeeCookie(name) {
    const cookie = await this.grabCookie(name)
    empty(`cookie ${name} not to be set`).assert(cookie)
  }

  /**
   * {{> grabCookie }}
   *
   * Returns cookie in JSON format. If name not passed returns all cookies for this domain.
   */
  async grabCookie(name) {
    if (!name) {
      const getCookie = ClientFunction(() => {
        return document.cookie.split(';').map(c => c.split('='))
      }).with({ boundTestRun: this.t })
      const cookies = await getCookie()
      return cookies.map(cookie => ({ name: cookie[0].trim(), value: cookie[1] }))
    }
    const getCookie = ClientFunction(
      () => {
        const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)')
        return v ? v[2] : null
      },
      { dependencies: { name } },
    ).with({ boundTestRun: this.t })
    const value = await getCookie()
    if (value) return { name, value }
  }

  /**
   * {{> clearCookie }}
   */
  async clearCookie(cookieName) {
    const clearCookies = ClientFunction(
      () => {
        const cookies = document.cookie.split(';')

        for (let i = 0; i < cookies.length; i++) {
          const cookie = cookies[i]
          const eqPos = cookie.indexOf('=')
          const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie
          if (cookieName === undefined || name === cookieName) {
            document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`
          }
        }
      },
      { dependencies: { cookieName } },
    ).with({ boundTestRun: this.t })

    return clearCookies()
  }

  /**
   * {{> waitInUrl }}
   */
  async waitInUrl(urlPart, sec = null) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    const clientFn = createClientFunction(
      urlPart => {
        const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
        return currUrl.indexOf(urlPart) > -1
      },
      [urlPart],
    ).with({ boundTestRun: this.t })

    return waitForFunction(clientFn, waitTimeout).catch(async () => {
      const currUrl = await this.grabCurrentUrl()
      throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
    })
  }

  /**
   * {{> waitUrlEquals }}
   */
  async waitUrlEquals(urlPart, sec = null) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    const baseUrl = this.options.url
    if (urlPart.indexOf('http') < 0) {
      urlPart = baseUrl + urlPart
    }

    const clientFn = createClientFunction(
      urlPart => {
        const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
        return currUrl === urlPart
      },
      [urlPart],
    ).with({ boundTestRun: this.t })

    return waitForFunction(clientFn, waitTimeout).catch(async () => {
      const currUrl = await this.grabCurrentUrl()
      throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
    })
  }

  /**
   * {{> waitForFunction }}
   */
  async waitForFunction(fn, argsOrSec = null, sec = null) {
    let args = []
    if (argsOrSec) {
      if (Array.isArray(argsOrSec)) {
        args = argsOrSec
      } else if (typeof argsOrSec === 'number') {
        sec = argsOrSec
      }
    }
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    const clientFn = createClientFunction(fn, args).with({ boundTestRun: this.t })

    return waitForFunction(clientFn, waitTimeout)
  }

  /**
   * {{> waitNumberOfVisibleElements }}
   */
  async waitNumberOfVisibleElements(locator, num, sec) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    return this.t
      .expect(createSelector(locator).with({ boundTestRun: this.t }).filterVisible().count)
      .eql(num, `The number of elements (${new Locator(locator)}) is not ${num} after ${sec} sec`, {
        timeout: waitTimeout,
      })
      .catch(mapError)
  }

  /**
   * {{> waitForElement }}
   */
  async waitForElement(locator, sec) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    return this.t.expect(createSelector(locator).with({ boundTestRun: this.t }).exists).ok({ timeout: waitTimeout })
  }

  /**
   * {{> waitToHide }}
   */
  async waitToHide(locator, sec) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    return this.t.expect(createSelector(locator).filterHidden().with({ boundTestRun: this.t }).exists).notOk({ timeout: waitTimeout })
  }

  /**
   * {{> waitForInvisible }}
   */
  async waitForInvisible(locator, sec) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    return this.t.expect(createSelector(locator).filterVisible().with({ boundTestRun: this.t }).exists).ok({ timeout: waitTimeout })
  }

  /**
   * {{> waitForText }}
   *
   */
  async waitForText(text, sec = null, context = null) {
    const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout

    let els
    if (context) {
      els = await findElements.call(this, this.context, context)
      await this.t.expect(els.exists).ok(`Context element ${context} not found`, { timeout: waitTimeout })
    } else {
      els = await findElements.call(this, this.context, '*')
    }

    return this.t
      .expect(els.withText(text).filterVisible().exists)
      .ok(`No element with text "${text}" found in ${context || 'body'}`, { timeout: waitTimeout })
      .catch(mapError)
  }
}

async function waitForFunction(browserFn, waitTimeout) {
  const pause = () =>
    new Promise(done => {
      setTimeout(done, 50)
    })

  const start = Date.now()

  while (true) {
    let result
    try {
      result = await browserFn()
    } catch (err) {
      throw new Error(`Error running function ${err.toString()}`)
    }

    if (result) return result

    const duration = Date.now() - start
    if (duration > waitTimeout) {
      throw new Error('waitForFunction timed out')
    }
    await pause() // make polling
  }
}

const createSelector = locator => {
  locator = new Locator(locator, 'css')
  if (locator.isXPath()) return elementByXPath(locator.value)
  return Selector(locator.simplify())
}

const elementByXPath = xpath => {
  assert(xpath, 'xpath is required')

  return Selector(
    () => {
      const iterator = document.evaluate(xpath, document, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null)
      const items = []

      let item = iterator.iterateNext()

      while (item) {
        items.push(item)
        item = iterator.iterateNext()
      }

      return items
    },
    { dependencies: { xpath } },
  )
}

const assertElementExists = async (res, locator, prefix, suffix) => {
  if (!res || !(await res.count) || !(await res.nth(0).tagName)) {
    throw new ElementNotFound(locator, prefix, suffix)
  }
}

async function findElements(matcher, locator) {
  if (locator && locator.react) throw new Error('react locators are not yet supported')

  locator = new Locator(locator, 'css')

  if (!locator.isXPath()) {
    return matcher ? matcher.find(locator.simplify()) : Selector(locator.simplify()).with({ timeout: 0, boundTestRun: this.t })
  }

  if (!matcher) return elementByXPath(locator.value).with({ timeout: 0, boundTestRun: this.t })

  return matcher.find(
    (node, idx, originNode) => {
      const found = document.evaluate(xpath, originNode, null, 5, null)
      let current = null
      while ((current = found.iterateNext())) {
        if (current === node) return true
      }
      return false
    },
    { xpath: locator.value },
  )
}

async function proceedClick(locator, context = null) {
  let matcher

  if (context) {
    const els = await this._locate(context)
    await assertElementExists(els, context)
    matcher = await els.nth(0)
  }

  const els = await findClickable.call(this, matcher, locator)
  if (context) {
    await assertElementExists(els, locator, 'Clickable element', `was not found inside element ${new Locator(context).toString()}`)
  } else {
    await assertElementExists(els, locator, 'Clickable element')
  }

  const firstElement = await els.filterVisible().nth(0)

  return this.t.click(firstElement).catch(mapError)
}

async function findClickable(matcher, locator) {
  if (locator && locator.react) throw new Error('react locators are not yet supported')

  locator = new Locator(locator)
  if (!locator.isFuzzy()) return (await findElements.call(this, matcher, locator)).filterVisible()

  let els

  // try to use native TestCafe locator
  els = matcher ? matcher.find('a,button') : createSelector('a,button')
  els = await els.withExactText(locator.value).with({ timeout: 0, boundTestRun: this.t })
  if (await els.count) return els

  const literal = xpathLocator.literal(locator.value)

  els = (await findElements.call(this, matcher, Locator.clickable.narrow(literal))).filterVisible()
  if (await els.count) return els

  els = (await findElements.call(this, matcher, Locator.clickable.wide(literal))).filterVisible()
  if (await els.count) return els

  els = (await findElements.call(this, matcher, Locator.clickable.self(literal))).filterVisible()
  if (await els.count) return els

  return findElements.call(this, matcher, locator.value) // by css or xpath
}

async function proceedIsChecked(assertType, option) {
  const els = await findCheckable.call(this, option)
  assertElementExists(els, option, 'Checkable')

  const selected = await els.checked

  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
}

async function findCheckable(locator, context) {
  assert(locator, 'locator is required')
  assert(this.t, 'this.t is required')

  let contextEl = await this.context
  if (typeof context === 'string') {
    contextEl = (await findElements.call(this, contextEl, new Locator(context, 'css').simplify())).filterVisible()
    contextEl = await contextEl.nth(0)
  }

  const matchedLocator = new Locator(locator)
  if (!matchedLocator.isFuzzy()) {
    return (await findElements.call(this, contextEl, matchedLocator.simplify())).filterVisible()
  }

  const literal = xpathLocator.literal(locator)
  let els = (await findElements.call(this, contextEl, Locator.checkable.byText(literal))).filterVisible()
  if (await els.count) {
    return els
  }

  els = (await findElements.call(this, contextEl, Locator.checkable.byName(literal))).filterVisible()
  if (await els.count) {
    return els
  }

  return (await findElements.call(this, contextEl, locator)).filterVisible()
}

async function findFields(locator) {
  const matchedLocator = new Locator(locator)
  if (!matchedLocator.isFuzzy()) {
    return this._locate(matchedLocator)
  }
  const literal = xpathLocator.literal(locator)

  let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
  if (await els.count) {
    return els
  }

  els = await this._locate({ xpath: Locator.field.labelContains(literal) })
  if (await els.count) {
    return els
  }
  els = await this._locate({ xpath: Locator.field.byName(literal) })
  if (await els.count) {
    return els
  }
  return this._locate({ css: locator })
}

module.exports = TestCafe
